
昨天那張 12 GiB 預算表替 LLM、embedding、reranker 三張嘴分好了帳,第三張地圖收工。三週下來的帳很好看:Day 01 那個 45 秒的問題,關掉 thinking、換一顆塞得進顯存的量化檔剩 5 秒,再修四刀,現在不到 2 秒。
然後你會遇到一件比 45 秒更尷尬的事:它用不到兩秒,非常有自信地答錯,而且錯得很體面——有分段、有語氣,有時候還附頁碼。使用者不會說「你的檢索沒撈到」,只會說一句「這個怎麼是錯的」,然後不再打開它。
Uber 公開過內部 on-call copilot Genie 的線上數字:70,000 個以上的問題、154 個 Slack channel,helpfulness 48.9%【廠商自述,uber.com/blog/genie-ubers-gen-ai-on-call-copilot/,2024-10-10】。
這個數不是檢索指標,是使用者在 Slack 上自選回報按出來的,不能拿去跟你自己那份驗證題庫(golden set)算出來的 recall 比(分母為什麼可疑,D28 會回來拆)。
它只有一個用途:告訴你真實流量的體感離 demo 有多遠。一線大廠、專人維運,還不到一半。所以你的 RAG 讓你不滿意,不代表你做得特別差,但也不代表沒得修。
今天開第四張地圖。第一格不是任何一個參數,是一句話:「RAG 答錯」不是一個 bug,是一個複合事件。
「慢」有現成的尺:Day 02 把它拆成 TTFT、TPOT、E2E,每一刀砍下去都量得出省了幾毫秒。「不準」沒有——你看到一個錯答案,中間隔著七道工序,每一道都可能是兇手,症狀卻長得一模一樣。
七層的修法互不替代:檢索沒撈到,換再強的生成模型也沒用;生成不忠實,候選池開再大也沒用。不知道病在哪一層時,任何調參都是擲骰子——而骰子偶爾真的會中,那才最糟。 今天要做的:把「不準」這個字禁用掉,換成七個可以分開量的格子。

圖 1:使用者的問題只碰得到下面那一帶——上面那三層在建索引端留下的錯,在他按下 Enter 之前就已經定案了。
| 層 | 典型症狀 | 最小驗證方式 | 修法方向 |
|---|---|---|---|
| L1 Parsing | 答案明明在文件裡卻怎麼都撈不到;表格數字全錯 | 先算空白或極短 chunk 的比例,再隨機抽 5 個 chunk 看原文 | 換 parser、補 OCR、表格自成 chunk |
| L2 Chunking | 答案被切成兩半;或命中的 chunk 很大、只有一句相關 | 拿 gold_quote 去 grep 全部 chunk,看它是否落在單一 chunk |
small-to-big:檢索小 child、生成餵大 parent |
| L3 Embedding | 查型號、料號、錯誤碼特別爛;版本錯的反而排很前 | 同一組專有名詞 query,dense 單跑、BM25 單跑,比 recall@50 | 開 hybrid(中文先驗斷詞) |
| L4 Retrieval | 換 reranker、換 prompt、換模型都毫無反應 | 量 recall@100:答案在不在候選池裡 | 加大候選池、hybrid、用 metadata 限定搜尋範圍(分域) |
| L5 Ranking | recall@50 很漂亮,recall@5 很難看 | 比較 recall@5 與 recall@50 的落差 | cross-encoder reranker |
| L6 Assembly | 檢索對了但抓錯段,context 一長特別明顯 | 正確 chunk 放在 context 頭、中、尾各跑一次 | 去重、最相關的放頭尾、每段前面標上來源與版本 |
| L7 Generation | context 裡明明有答案,模型還是自己編 | 逐句檢查每一句說法能不能被 context 支撐 | 要求答案附上來源、「找不到就說不知道」、換模型 |
L1 先跑健檢,再用眼睛看。 一份 80MB、七十幾頁的 PDF 每頁超過 1 MB【推算,80 MB ÷ 75 頁 ≈ 1.07 MB/頁】——純文字 PDF 一頁通常幾十到幾百 KB。1 MB 一頁在暗示掃描件、大量圖片、表格(Day 27 分開處理)或複雜向量圖,那幾種都會讓純文字抽取拿到空字串。
檔案大小只能起疑不能定罪,先抽頁看 parser 實際抽出了什麼:空白或極短 chunk 超過一兩成就先修【經驗閾值,非通用標準】。這時候「chunk 數會影響準度」是真的,但那是噪音不是訊號。
L1–L3 在文件端改一項就要重建索引,L4–L7 多數修改重跑一次查詢就看得到。 兩邊各有例外:查詢端的 instruction 前綴改了不必重建,而 L4 的 metadata schema 與索引參數反而要。這也決定了診斷順序:先用不必重建索引的實驗定位。
L4 和 L5 長得像,但差一個字。 reranker 是排序的藥,不是候選池的藥——它能把正確答案從第 30 名拉到第 3 名、讓最終的 recall@5 變好看,但沒撈進候選池的東西它一個也變不出來。
實驗 A:標準答案測試(oracle test)——先問「只放正確 chunk,答不答得出來」。 把 golden set 裡的正確 chunk 直接手動貼進 prompt,檢索整段跳過不跑。
gold_quote:那段文字裡真的有答案嗎?沒有就是 L1/L2 或標註的問題;有卻還是錯,範圍才縮到 L6/L7。第二條常被寫成「病在 L1–L5」。這個測試跳過的不只是檢索:排序、去重、截斷、來源標註樣板都沒有,所有干擾段也被拿掉了——正式環境的 L6 不在它的受測範圍內。
想再往下分:把正確 chunk 放進正式候選池、照原本的排序與組裝走完,記錄一次它有沒有進到最終 prompt。沒進去是 L5 或截斷;進去了還錯仍然是 L6 或 L7。
它還附贈一條參考基準:Anthropic 建議知識庫小於 200,000 tokens(約 500 頁)就整包塞進 prompt、開 prompt caching,不必做 RAG
【引用,Anthropic, "Introducing Contextual Retrieval" 2024-09】。
那是工程建議、不是被證明過的門檻,而且只有同一顆模型、同一組 prompt 兩邊各跑一次,差距才近似檢索與組裝造成的損失。
也別當成硬上界,而且你多半跑不起來:正確段落放在頭、中、尾,正確率本來就不一樣;Qwen3-8B 官方只到 32,768 native、YaRN 驗證到 131,072
【官方,huggingface.co/Qwen/Qwen3-8B 】,200K 的 KV 要 27.5 GiB、開 FP8 也要 13.7【推算,200,000 × 144 KiB ≈ 27.47 GiB;FP8 減半 ≈ 13.73】,D21 那張 12 GiB 的卡裝不下。機器與模型都換掉之後,差距就混進模型本身的能力了。
實驗 B:recall 階梯——分開 L4 與 L5。 同一組 query,量 recall@5 / @10 / @20 / @50 / @100,看曲線的形狀。量的是第一階檢索器:跑之前先把 reranker 從 my_retriever 拿掉,不然量到的是整條管線的結果,看不出第一階檢索到底有沒有把答案撈進候選池。
| 形狀 | 判讀 | 修法 |
|---|---|---|
| recall@5 低、recall@50 高(示意:0.45 → 0.92) | 排序問題(L5),找得到只是排不前面 | reranker,通常立刻見效 |
| recall@5 低、recall@100 也低(示意:0.40 → 0.48) | recall miss(L4),答案不在候選池 | 候選池裡沒有,reranker 也救不了;要修的是第一階檢索——hybrid、限定範圍,或上游的 L1 / L2 |
| recall@5 就很高、線上仍答錯 | L6,或有干擾才失敗的 L7 | 順序、去重、截斷、缺來源標註 |
括號裡那兩組是形狀示意,不是實測——你要認的是「兩端差很多」還是「兩端一樣爛」,絕對值請自己量。

圖 2:走完兩個分叉,你拿到的是「哪一層」,不是「哪個參數」。
兩個實驗合起來大概半天。recall 那半段不需生成 token(查詢向量化與向量搜尋還是要跑),便宜到可以掛進 CI。
oracle 那半段,30–50 題先用人眼判最可靠:同一個意思,模型可能寫「250C」也可能寫「攝氏兩百五十度」,直接拿 exact match 判分會把答對的判成答錯。
答錯之後,多數人的第一個動作是打開設定檔改 chunk size。三個理由:

圖 3:兩頭都有風險,所以往哪個方向轉都解釋得通。
一、它同時牽動三層。 三條線各有兩頭的風險,方向還不一致——同時在三層動手,分數變好變壞都記不到誰頭上。
二、它是文件內的旋鈕,救不了跨文件的病。 文件累積到上千份之後準度崩掉,是搜尋空間變大造成的稀釋,單靠 chunk size 治不好:它可能讓分數移動,卻不會讓資料量較少的類別在全域索引裡變顯眼。這是 Day 25 的主題。
三、上游很可能根本是壞的,見上一節。
所以本週的作業順序固定:先驗 L1 → 再跑 oracle 與 recall 階梯 → 最後才輪到旋鈕。 至於旋鈕有多不直覺,Day 24 會用公開實測證明:很多團隊從第一天就在用的那組預設值,在 token 級指標上接近最差【引用,Chroma, "Evaluating Chunking Strategies for Retrieval", 2024-07,trychroma.com/research/evaluating-chunking 】。
還有一句先立在這裡,它會貫穿 D25 到 D28:檢索指標變好,不代表答案更可信。 一份 2026 年的預印本裡,用 metadata 限定範圍讓 P@10 從 0.77 升到 0.86,但疊上多代理流程之後,faithfulness 反而從 0.61 掉到 0.35【引用,預印本 arXiv:2606.11350,2026-06,Table 3】。
recall 大幅下降也是多代理造成的,不是限定範圍本身。不過同一篇的其他表格出現相反方向,所以能借的是這個問題意識,不是它的絕對值。
你量出來的診斷卡,決定這週該讀哪幾天。
不需要新硬體,但需要一份 golden set,30 到 50 條起步,一天內建得完。唯一不能省的是每條都要標出答案在哪——這是把 L4 / L5 和 L7 分開量的唯一方法,也是最多人省略、然後永遠診斷不出病灶的一步。
但標成什麼,決定了它能活多久。腳本比對的是 gold_doc_ids,而那串 id 綁在當下這一版切法上:Day 24 一動 chunk size 就整份作廢。所以同時把穩定的證據存下來——文件、頁碼、原文那一句;換過切法重新映射就好,題目不必重寫。
# day22_triage.py --exp b # recall 階梯: 純 ID 比對, 不碰生成模型
# --exp a # 標準答案測試: 跳過檢索, 只需要生成模型
# golden.jsonl 每行: {"question", "gold_doc_ids", "gold_quote", "gold_answer"}
# my_embed / load_chunk / ask_llm 這三支是你自己的
import argparse, json
def my_retriever(docs, question, k): # <- 你自己的檢索接在這裡, 回傳已排序的 doc_id
# 自己算向量再送進去: near_text 要 collection 掛了 vectorizer 才能用,
# 而本系列的 embedding 一路是自己 serve 的
r = docs.query.near_vector(near_vector=my_embed(question), limit=k)
return [o.properties["doc_id"] for o in r.objects]
def recall_at_k(ret, gold, k):
return len(set(ret[:k]) & set(gold)) / max(1, len(set(gold)))
def exp_b(golden, retrieve, ks=(5, 10, 20, 50, 100)):
"""實驗 B: 看階梯的形狀"""
kmax = max(ks)
# 只檢索一次取 kmax 再切片; 代價是 HNSW 的 ef 跟著 limit 走,
# 切片的小 k 會比真的 limit=5 樂觀, 階梯偏平
hits = [(retrieve(g["question"], kmax), g["gold_doc_ids"]) for g in golden]
for k in ks:
r = sum(recall_at_k(ret, gold, k) for ret, gold in hits) / len(hits)
print(f"recall@{k:<3} = {r:.3f}")
def exp_a(golden):
"""實驗 A: 跳過檢索, 直接餵正確 chunk; 對錯自己判"""
for g in golden:
ctx = "\n\n".join(load_chunk(i) for i in g["gold_doc_ids"])
quote = g.get("gold_quote", "")
# 餵進去的東西裡真的有答案嗎? 沒有就不是模型的錯, 是 L1/L2 或標註的錯
in_ctx = (quote in ctx) if quote else None
got = ask_llm(context=ctx, question=g["question"])
print(g["question"], "| quote_in_ctx:", in_ctx,
"| gold:", g.get("gold_answer", "(未標)"), "| got:", got)
if quote and not in_ctx:
print(" ctx =", repr(ctx))
ap = argparse.ArgumentParser()
ap.add_argument("--exp", choices=["a", "b"], default="b")
golden = [json.loads(line) for line in open("golden.jsonl", encoding="utf-8")]
if ap.parse_args().exp == "a":
exp_a(golden) # 完全不碰向量庫, 也不用裝 weaviate
else:
import weaviate # 只有實驗 B 需要; D25 沿用這條連線
with weaviate.connect_to_local() as client:
docs = client.collections.get("Docs")
exp_b(golden, lambda q, k: my_retriever(docs, q, k))
# 1) 準備 golden set: 每行一題, gold_doc_ids 是必填欄位
cat > golden.jsonl <<'EOF'
{"question":"熱處理爐 A 型的溫度上限是多少?","gold_doc_ids":["spec_v2#p12"],"gold_quote":"最高使用溫度為 250C","gold_answer":"250C"}
{"question":"PROD-SKU-7842X 的驗收標準?","gold_doc_ids":["qa_manual#p3"],"gold_quote":"250C 恆溫保持 30 分鐘後空冷","gold_answer":"250C 保持 30 分鐘"}
EOF
# 2) 實驗 B: recall 階梯(純 ID 比對, 不碰 LLM, 幾秒鐘跑完)
# 前提是向量庫與 embedding 服務都起來了 -- 本系列到 D25 才正式裝庫,
# 想今天就跑, 把 my_retriever 換成你現有的檢索即可
python day22_triage.py --exp b
# 3) 實驗 A: oracle test 才需要生成模型; 沿用 D21 預算表那顆
# vllm serve 會佔住這個 terminal, 另開一個跑下面那行
CUDA_VISIBLE_DEVICES=0 vllm serve Qwen/Qwen3-8B-AWQ \
--gpu-memory-utilization 0.60 --max-model-len 8192 --kv-cache-dtype fp8 --port 8000
python day22_triage.py --exp a
30–50 題足夠先找方向。要把單一 slice 的 80% 通過率估到 95% 信賴水準、± 5 個百分點,才需要約 246 筆【n = 1.96² × 0.8 × 0.2 ÷ 0.05² ≈ 245.9】;要比較兩版相差 2%、3% 算不算顯著,則要另做配對檢定,例如 McNemar。
這一篇不附我的階梯數字:輸出由你的語料與 golden set 決定,要帶走的是那張形狀判讀表。
明天 Day 23〈MTEB 第一名不是你的第一名:embedding 選型與授權地雷〉——同一張榜單上前幾名可能統計上根本沒差別,而分數最高的那顆,說不定連商用都不能用。
咱們明天見。